Skip to content

feat(credits): credit ledger, subscription plans, and a metered nodetool provider - #4760

Merged
georgi merged 4 commits into
mainfrom
claude/agentic-video-product-wvx6ri
Aug 6, 2026
Merged

feat(credits): credit ledger, subscription plans, and a metered nodetool provider#4760
georgi merged 4 commits into
mainfrom
claude/agentic-video-product-wvx6ri

Conversation

@georgi

@georgi georgi commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Credit management and subscription plans for the Studio product (follow-up to #4758), with metering modeled as a provider, not a deployment switch: NodeTool's own managed models are a real provider (nodetool) whose spend is metered against the user's credit balance, while BYOK providers coexist unmetered on the same server.

Design

  • Balance = grants − spend. nodetool_credit_ledger stores grants only (plan accruals, top-ups, adjustments). Spend is read from the nodetool_predictions rows every provider call already writes, at 1 credit = $0.01 — no double bookkeeping.
  • Plans without a cron. nodetool_user_subscriptions holds one row per user (Free 300/mo, Creator 3,000/mo $12, Pro 10,000/mo $40). Monthly grants accrue lazily on the first status read of the month, keyed plan:<user>:<plan>:<YYYY-MM> so the primary key makes double accrual impossible.
  • The nodetool provider is what gets metered. Each curated model (NODETOOL_MODELS in protocol) names a delegate provider+model; NodetoolProvider runs the delegate on platform-owned keys (NODETOOL_PLATFORM_FAL_KEY, NODETOOL_PLATFORM_ANTHROPIC_KEY), absorbs the delegate's cost at the delegate's price, and lists only funded models — the provider appears in pickers exactly when the server can serve it. model-pricing translates nodetool/... ids to the delegate before catalog lookup, so estimates are real numbers.
  • Enforcement follows the provider. Workflow runs are gated only on the nodetool slice of their cost estimate (estimateNodetoolSpendadmitCreditRun, beside the existing application-budget gate); the direct generate_media/transcribe_audio RPCs are gated only when called with provider: "nodetool". BYOK is never gated. The gate fails open on its own errors, like the app-budget gate.
  • Payments are a stub on purpose. credits.topup adds credits with no payment behind it and the UI says so; a payment provider integration replaces that mutation with a checkout session writing the same ledger rows from its webhook.

Changes

  • packages/protocol: PROVIDER_IDS.NODETOOL, curated catalog nodetool-models.ts, api-schemas/credits.ts (status reports meteredProvider), cloud-profile inclusion.
  • packages/runtime: providers/nodetool-provider.ts (delegating provider, cost absorption, funded-model listing) + registration.
  • packages/model-pricing: nodetool→delegate id translation in getModelUnitPrice.
  • packages/models: credits.ts (catalog, subscription, accrual, balance, checkCredits), schema + pg mirror, migration 20260806_000000, test-DB DDL.
  • packages/websocket: credit-gate.ts, trpc/routers/credits.ts, provider-scoped gating in unified-websocket-runner.ts.
  • web/src/studio: curated models point at the nodetool provider; server-backed credits chip; /studio/account page (balance, usage, plan cards, test top-up); provider display name.
  • docs/agentic-video-product.md rewritten to match.

Verification

  • Tests: credits model (7), credit gate (3), NodetoolProvider (4 — funded filtering, provider stamping, missing-key and unknown-id errors), pricing translation (2), migration-count bump. Full models suite green (814); websocket suite green (2,211); web typecheck + eslint green.
  • Live server smoke: with NODETOOL_PLATFORM_FAL_KEY set, models.imageByProvider/videoByProvider for nodetool return exactly the funded curated models stamped provider: "nodetool"; the anthropic-delegated director is absent without its key. /studio/account renders; Free→Creator switch moves the balance 300 → 3,300 instantly.

🤖 Generated with Claude Code

https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2

claude added 2 commits August 6, 2026 10:47
Server-owned credits and plans for the Studio product:

- nodetool_credit_ledger + nodetool_user_subscriptions tables (migration
  20260806_000000, both dialects, test-DB DDL). The ledger stores grants
  only; balance = sum(grants) - ceil(prediction spend / 1 cent), so spend
  is never double-booked.
- Plan catalog (Free 300/mo, Creator 3000/mo, Pro 10000/mo) with lazy,
  idempotent monthly accrual keyed plan:<user>:<plan>:<YYYY-MM> — no cron.
- trpc.credits.status/setPlan/topup (schemas in protocol). Top-up is an
  explicit no-payment stub; a payment provider replaces it later.
- Spend gate (credit-gate.ts), off by default: with
  NODETOOL_CREDITS_ENFORCED=1 workflow runs are refused on an empty
  balance (BUDGET_EXCEEDED, next to the application-budget gate) and the
  direct generate_media / transcribe_audio RPCs throw. Fails open on
  gate errors like the app gate.
- Studio UI: server-backed credits chip linking to /studio/account —
  balance, usage, plan cards, test top-up.
- Tests: credits model (7), credit gate (3), migration count bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
Replace the deployment-wide NODETOOL_CREDITS_ENFORCED switch with a real
provider: NodeTool's own managed models.

- New provider id "nodetool" (PROVIDER_IDS) with a curated catalog in
  protocol (NODETOOL_MODELS): each model names a delegate provider+model.
  NodetoolProvider runs delegates on platform-owned keys
  (NODETOOL_PLATFORM_FAL_KEY / NODETOOL_PLATFORM_ANTHROPIC_KEY), absorbs
  the delegate's cost as its own, and lists only funded models — so the
  provider appears in pickers exactly when the server can serve it.
- model-pricing translates nodetool/... ids to the delegate before
  catalog lookup, so estimates for metered spend are real numbers.
- Credit gate now follows the provider, not the deployment: workflow
  runs are gated on the nodetool slice of their cost estimate only
  (estimateNodetoolSpend), direct generate_media/transcribe_audio RPCs
  are gated only when called with provider "nodetool", and BYOK
  providers are never gated. The env var is gone; credits and
  bring-your-own-key coexist per call on one server.
- credits.status now reports meteredProvider instead of enforced;
  Studio's curated models point at the nodetool provider, and the
  account page copy explains the split.
- Tests: NodetoolProvider (funded filtering, delegation errors),
  pricing translation, gate without the env var.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
@georgi georgi changed the title feat(credits): user credit ledger, subscription plans, and spend gate feat(credits): credit ledger, subscription plans, and a metered nodetool provider Aug 6, 2026
The provider contract suite requires every registered provider to ship a
chat cassette or a justified exemption. NodetoolProvider makes no wire
calls of its own — chat routes to the delegate named in NODETOOL_MODELS,
whose own cassette covers the contract — and an id outside the curated
catalog rejects cleanly, which the exemption test asserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2

@georgi georgi left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found five accounting and enforcement issues that should be addressed before this is used with platform-owned provider keys. Inline comments include concrete failure modes and suggested fixes.

Comment thread packages/models/src/credits.ts Outdated
const spendRows = await db
.select({ total: sql<number>`COALESCE(SUM(${predictions.cost}), 0)` })
.from(predictions)
.where(eq(predictions.user_id, userId));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This aggregation needs to be scoped to provider = "nodetool". As written, every prediction for the user, including fal_ai, anthropic, and other BYOK calls, is deducted from the managed credit balance. Existing users with historical BYOK spend can therefore start at zero, and future BYOK calls continue draining credits, contradicting the provider-scoped contract. Please add the provider predicate and a regression test with both managed and BYOK predictions.

.input(topupInput)
.output(creditStatusOutput)
.mutation(async ({ ctx, input }) => {
await grantCredits(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lets any authenticated caller mint up to 50,000 credits per request, repeatedly. Because those credits unlock calls made with platform-owned keys, the RPC makes managed provider spend effectively unlimited; labeling the UI button as a test does not protect the endpoint. Please remove or gate this behind an explicit development-only configuration, or make grants admin/payment-webhook-only before exposing the router.

// user's own keys. Direct generation has no per-node estimate, so the
// gate refuses on an empty balance.
if (req.provider === "nodetool") {
const creditDecision = await admitSpend(userId, 0);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A zero-cost check only verifies that the balance is currently positive. This direct RPC does not write a Prediction, so a user who has received the free grant can call generate_media indefinitely through the platform key, including the variations fan-out, without ever reducing the balance. The same issue exists in transcribe_audio. Please reserve and record/settle the actual provider cost for these paths, or disable the managed provider here until that accounting exists.

async runJob(req: RunJobRequest): Promise<void> {
req._accepted_at_ms ??= performance.now();
if (!(await this.admitApplicationRun(req))) return;
if (!(await this.admitCreditRun(req))) return;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check happens before concurrency queueing and does not reserve credits. Multiple submissions can all pass against the same balance, then queued jobs execute later without another check after earlier jobs have spent it. A small balance can therefore authorize substantially more platform spend than it contains. Please atomically reserve estimated credits per accepted run and settle against actual cost, or at minimum recheck/reserve when a queued run is dequeued, following the application-budget pattern.

try {
return await call();
} finally {
this._absorbedCost += inner.getTotalCost() - before;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cached inner provider can serve overlapping calls, so measuring a call via the shared cumulative getTotalCost() is not concurrency-safe. If A and B both read 0, A adds x, then B finishes at x + y, the outer provider absorbs x and then x + y, double-counting A. generateMessages has the same pattern. Please use request-scoped cost/usage, a fresh delegate per concurrent call, or serialize accounting around each shared delegate.

- Scope the balance's spend aggregation to provider "nodetool": BYOK
  predictions (fal_ai, anthropic, ...) no longer drain credits, and
  historical BYOK spend no longer zeroes new balances. Regression test
  covers mixed managed/BYOK rows.
- Gate the no-payment top-up behind NODETOOL_ENABLE_TEST_TOPUP (off by
  default): the mutation throws FORBIDDEN otherwise, status reports
  testTopupEnabled, and the UI hides the button.
- Direct generate_media now admits against a unit-price estimate,
  reserves it for the duration of the call, and records a prediction row
  (max of delegate-tracked cost and the unit estimate) so the balance
  decrements; transcribe_audio records delegate-tracked cost likewise.
- Workflow runs reserve their nodetool estimate when admitted and
  release at the terminal state or cancel-while-queued, so concurrent or
  queued submissions can't multiply a small balance (process-local, TTL-
  bounded; a multi-instance deployment moves this into the DB like
  application-budgets).
- NodetoolProvider builds a fresh delegate per call instead of sharing a
  cached instance, so absorbing cost off the delegate's cumulative
  counter can't double-count overlapping calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2

georgi commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

All five findings addressed in ddfd5fb:

  1. BYOK spend draining credits — the balance's spend SUM is now scoped to provider = "nodetool", with a regression test mixing managed and BYOK prediction rows.
  2. Open credit mintcredits.topup throws FORBIDDEN unless the operator sets NODETOOL_ENABLE_TEST_TOPUP; credits.status reports the flag and the UI hides the button when it's off.
  3. Unaccounted direct RPCsgenerate_media on the managed provider now admits against a unit-price estimate (variations included), reserves it for the call, and writes a prediction row at max(delegate-tracked cost, unit estimate) so the balance decrements; transcribe_audio records delegate-tracked cost the same way.
  4. Queue/concurrency race — admitted runs reserve their nodetool estimate under the job id and release at terminal state or cancel-while-queued; admitSpend counts in-flight reservations, so N submissions can no longer all pass against the same balance. Reservations are process-local with a TTL backstop — the comment notes a multi-instance deployment should move them into the DB like application_budgets.
  5. Delegate cost double-countNodetoolProvider constructs a fresh delegate per call (delegates build clients lazily, so this is cheap), making the cost-absorption window race-free.

Generated by Claude Code

@georgi
georgi merged commit 3e8a0b3 into main Aug 6, 2026
28 checks passed
@georgi
georgi deleted the claude/agentic-video-product-wvx6ri branch August 6, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants